package AddonSVN;

use strict;
use warnings;

use SVN::Client;
use SVN::Core;
use SVN::Wc;
use URI;
use File::Spec;
use File::Spec::Unix;
use IO::Pipe;
use Cwd qw(realpath);

BEGIN {
    eval { require XML::Parser::Expat; } and eval { import XML::Parser::Expat; };
}
our $has_xml_expat = ( defined &XML::Parser::Expat::new );

BEGIN {
    eval { require Data::Dumper; } and eval { import Data::Dumper; $Data::Dumper::Sortkeys = 1; $Data::Dumper::Indent = 1; };
}
our $has_dumper = ( defined &Data::Dumper::new );

use Constants qw( :dir :file :vinfo :svn :source );
use Options;
use Database;
use AddonKey;
use AddonDiff;
use FormatString;
use TOCInfo;

BEGIN {
    use Exporter ();
    our ( $VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS );

    $VERSION = sprintf( "%d", q$Revision$ =~ m{\d+} );
    @ISA     = qw(Exporter);
    @EXPORT  = qw(&get_svn_directory_cache
        &get_svn_revision
        &get_svn_info
        &get_svn_status
        &get_svn_externals
        &get_svn_log
        &fetch_svn_to_path);
    %EXPORT_TAGS = ();
    @EXPORT_OK   = qw();
} ## end BEGIN

our $svn_ctx = new SVN::Client;

our %svn_authority_cache = ();
our %svn_problem_cache   = ();

our %svn_ls_cache     = ();
our %svn_ls_old_cache = ();
our %svn_info_cache   = ();
our %root_revisions   = ();
our %svn_fetch_cache  = ();

sub p_get_svn_root_path($$ );
sub get_svn_directory_cache($;$$ );
sub get_svn_revision($ );
sub get_svn_info($ );
sub get_svn_status($ );    # Modifications, Embeds (0, 1 or undef)
sub get_svn_externals($ );
sub get_svn_log($$$ );
sub fetch_svn_to_path($$$$$);

sub p_get_svn_root_path($$ ) {
    my ( $path, $url_string ) = @_;

    $url_string =~ s{/*$}{} unless ref $url_string;
    my $url_ref = URI->new( $url_string )->canonical;

    return $path, $url_ref
        unless ( -e $path );

    #### Try to find highest path still on the same repository
    my $real_path = realpath( File::Spec->canonpath( $path ) );
    my $subdir_name;
    my $subdir_info;
    eval {
        $svn_ctx->info(
            $path, undef, undef,
            sub {
                ( $subdir_name, $subdir_info ) = @_;
            },
            0
        );
    };

    return $path, $url_ref
        unless ( $subdir_info
        && $subdir_info->URL
        && $subdir_info->repos_root_URL
        && URI::eq( $subdir_info->URL, $url_ref ) );

    return $path, $url_ref
        if ( URI::eq( $subdir_info->URL, $subdir_info->repos_root_URL ) );

    my ( $vol, $dir, $file ) = File::Spec->splitpath( $real_path, 1 );
    my @path_dirs = File::Spec->splitdir( $dir );

    while ( @path_dirs ) {
        pop @path_dirs;
        my $try_path = File::Spec->catpath( $vol, File::Spec->catdir( @path_dirs ), "" );
        my $try_name;
        my $try_info;
        eval {
            $svn_ctx->info(
                $try_path,
                undef, undef,
                sub {
                    ( $try_name, $try_info ) = @_;
                },
                0
            );
        };
        last unless defined $try_info;
        last unless defined $try_info->URL;
        last unless defined $try_info->repos_root_URL;
        last unless $try_info->repos_root_URL eq $subdir_info->repos_root_URL;
        $path    = $try_path;
        $url_ref = $try_info->URL;
    } ## end while ( @path_dirs )
    if ( ref $url_ref ) {
        $url_ref = $url_ref->clone;
    }
    else {
        $url_string = $url_ref;
        $url_string =~ s{/*$}{};
        $url_ref = URI->new( $url_string );
    }
    $url_ref = $url_ref->canonical;

    return $path, $url_ref;
} ## end sub p_get_svn_root_path($$ )

# Cache information about entries in an SVN directory
sub get_svn_directory_cache($;$$ ) {
    return undef unless $has_xml_expat;
    my $uri        = URI->new( shift );
    my $force      = shift;
    my $weak_force = shift;

    if ( $opt_flags{ "weak" } && $force ) {
        undef $force;
        $weak_force = 1;
    }

    # 8 hours when fetching known addons and 24 hours in other cases
    # Always fetch if unknown addon
    my $min_age = $weak_force ? 28800 : 86400;
    $force = 1 unless $has_dumper;
    undef $force if $opt_flags{ "dryrun" };

    # Determine authority
    my $authority = $uri->authority;
    return undef unless defined $authority;
    my $db_name = $authority;
    $db_name =~ s{[^a-zA-Z0-9]+}{_}g;
    $db_name =~ s{^_+}{};
    $db_name =~ s{_$}{};
    $db_name = "dir." . $db_name;
    my $cache_file = database_file( $db_name );

    my $auth_cache = $svn_authority_cache{ $authority };
    unless ( $auth_cache ) {
        if ( -f $cache_file ) {
            $auth_cache = do $cache_file;
            $svn_authority_cache{ $authority } = $auth_cache;
        }
    } ## end unless ( $auth_cache )
    $auth_cache ||= {};
    $svn_authority_cache{ $authority } = $auth_cache;

    # Determine canonical uri
    my $root_uri = $uri->canonical->as_string;
    return undef unless $root_uri;

    return -1 if $svn_problem_cache{ $root_uri };

    my $uri_cache = $auth_cache->{ $root_uri } ||= {};
    my $uri_time  = $uri_cache->{ "time" };
    my $cache     = $uri_cache->{ "dirs" };

    if (   !defined $cache
        || !defined $uri_time
        || time > ( $uri_time + $min_age ) )
    {
        $force = 1;
    } ## end if ( !defined $cache ||...)

    if ( $force ) {
        $cache = {};
        $root_uri =~ s{/$}{};
        my $entries;
        if ( $opt_output{ "verbose" } ) {
            my $svn_cmd = find_command( "svn" ) || "svn";
            print_command( $svn_cmd, "ls", "--xml", $root_uri );
        }
        eval { $entries = $svn_ctx->ls( $root_uri, "HEAD", 0 ); };
        unless ( $entries ) {
            $svn_problem_cache{ $root_uri } = 1;
            return -1;
        }

        $uri_cache->{ "time" } = time;
        $uri_cache->{ "dirs" } = $cache;

        while ( my ( $name, $entry ) = each %$entries ) {
            next unless $entry->kind eq $SVN::Node::dir;
            my $key = addon_name_to_key( $name );
            $cache->{ $key } = {
                author   => $entry->last_author,
                date     => $entry->time,
                kind     => 'dir',
                name     => $name,
                revision => $entry->created_rev
            };
        } ## end while ( my ( $name, $entry...))

        if ( $has_dumper ) {

            # Dump the cache
            my $dumper = Data::Dumper->new( [ $auth_cache ], [ qw($auth_cache) ] );
            update_database_file( $db_name, $dumper->Terse( 1 )->Dump, time, "cache for " . $authority );
        } ## end if ( $has_dumper )
    } ## end if ( $force )

    return $cache;
} ## end sub get_svn_directory_cache($;$$ )

# Get SVN revisions
sub get_svn_revision($ ) {
    my $uri = shift;
    if ( ref $uri ) {
        $uri = $uri->clone;
    }
    else {
        $uri = URI->new( $uri );
    }
    my @pieces     = $uri->path_segments;
    my $last_piece = pop @pieces;
    return undef unless @pieces;
    $uri->path_segments( @pieces );
    my $root_uri = $uri->canonical->as_string;
    return undef unless $root_uri;
    my $cache = get_svn_directory_cache( $root_uri, 1 );
    return undef unless $cache;
    return -1 unless ref $cache;
    my $key = addon_name_to_key( $last_piece );
    my $rec = $cache->{ $key };
    $rec = $rec && $rec->{ "revision" };
    return $rec;
} ## end sub get_svn_revision($ )

# Get SVN data
sub get_svn_info($ ) {
    my $path = shift;
    return $path if ref $path;
    my $res;
    return $res unless defined $path;
    my $node = path_to_node( $path );
    my $key;

    if ( -d $path ) {
        my $entries = File::Spec->catfile( $path, &DIR_SVN, &FILE_SVN_ENTRIES );
        $key = get_file_key( $entries );
    }
    elsif ( -f $path ) {
        $key = get_file_key( $path );
    }
    if ( defined $key ) {
        $res = $svn_info_cache{ $key };
        unless ( defined $res ) {
            my $active_name;
            my $active_info;
            my $real_path = File::Spec->canonpath( $path );
            $real_path = realpath( $real_path ) if ( -l $real_path );

            eval {
                $svn_ctx->info(
                    $real_path,
                    undef, undef,
                    sub {
                        ( $active_name, $active_info ) = @_;
                    },
                    0
                );
            };
            if ( $active_name && $active_info ) {
                my $url = $active_info->URL;

                my ( $root_path, $root_url ) = p_get_svn_root_path( $real_path, $url );
                my ( $root_name, $root_info );
                if ( !URI::eq( $root_url, $url ) ) {
                    eval {
                        $svn_ctx->info(
                            $root_path,
                            undef, undef,
                            sub {
                                ( $root_name, $root_info ) = @_;
                            },
                            0
                        );
                    };
                } ## end if ( !URI::eq( $root_url...))
                unless ( $root_name && $root_info ) {
                    $root_name = $active_name;
                    $root_info = $active_info;
                    $root_path = $real_path;
                }

                my $svn_date = $active_info->last_changed_date;
                $svn_date =~ s{(\d{6})$}{.$1} if $svn_date;
                my $root_date = $root_info->last_changed_date;
                $root_date =~ s{(\d{6})$}{.$1} if $svn_date;
                $res = $svn_info_cache{ $key } = {
                    _KEY_                   => $key,
                    &VINFO_REPO_TYPE        => &SOURCE_SVN,
                    &VINFO_REPO_NAME        => $node,
                    &VINFO_REPO_PATH        => $active_name,
                    &VINFO_REPO_ROOT        => $root_name,
                    &VINFO_FILE_REVISION    => $active_info->last_changed_rev,
                    &VINFO_DIR_REVISION     => $active_info->rev,
                    &VINFO_PROJECT_REVISION => $root_info->rev
                        || $active_info->rev,
                    &VINFO_FILE_AUTHOR    => $active_info->last_changed_author,
                    &VINFO_PROJECT_AUTHOR => ( $root_info->last_changed_author
                            || $active_info->last_changed_author ),
                    &VINFO_FILE_TIMESTAMP    => $svn_date,
                    &VINFO_PROJECT_TIMESTAMP => $root_date
                        || $svn_date,
                    &VINFO_URL => $url,

                    &SVN_REPOSITORY_ROOT => $active_info->repos_root_URL,
                    "Repository UUID"    => $active_info->repos_UUID,
                    "Node Kind"          => $active_info->kind,
                    "Schedule"           => $active_info->schedule,
                };

                # Compute svn normalized_url
                $res->{ &SVN_NORMALIZED_URL } = unformat_string(
                    $url,
                    "name" => $node
                ) if defined( $url );
            } ## end if ( $active_name && $active_info)
            else {
                $res = $svn_info_cache{ $key } = "broken";
            }
        } ## end unless ( defined $res )
        return $res if ref $res;
        undef $res;
    } ## end if ( defined $key )

    return generic_get_info( $path );
} ## end sub get_svn_info($ )

sub get_svn_status($ ) {
    my $path = shift;
    $path = File::Spec->canonpath( $path );
    my ( $has_custom, $has_external );
    return ( $has_custom, $has_external ) unless defined $path;
    my $entries = File::Spec->catfile( $path, &DIR_SVN, &FILE_SVN_ENTRIES );
    if ( -e $entries ) {
        $has_custom = 0;
        eval {
            $svn_ctx->status(
                $path, undef,
                sub {
                    my ( $rel, $info ) = @_;
                    my $last = path_to_node( $rel );
                    return if $last eq "Icon\r";
                    my $text_status = $info->text_status;
                    my $prop_status = $info->prop_status;
                    if ( $text_status != $SVN::Wc::Status::normal ) {
                        if ( $text_status == $SVN::Wc::Status::external ) {
                            $has_external = 1;
                        }
                        else {
                            $has_custom = 1;
                        }
                    } ## end if ( $text_status != $SVN::Wc::Status::normal)
                    if (   $prop_status != $SVN::Wc::Status::none
                        && $prop_status != $SVN::Wc::Status::normal )
                    {
                        if ( $prop_status == $SVN::Wc::Status::external ) {
                            $has_external = 1;
                        }
                        else {
                            $has_custom = 1;
                        }
                    } ## end if ( $prop_status != $SVN::Wc::Status::none...)
                },
                1,
                1,
                0,
                0
            );
        } or return ( undef, undef );
        unless ( $has_external ) {
            ## Now we need to check if there are any externals available
            my $res = get_svn_externals( $path );
            eval { $res = $svn_ctx->propget( "svn:externals", $path, undef, 1 ); } or return ( undef, undef );
            $has_external = 0 if ( $res && %$res );
        } ## end unless ( $has_external )
    } ## end if ( -e $entries )
    return ( $has_custom, $has_external );
} ## end sub get_svn_status($ )

sub get_svn_externals($ ) {
    my $path = shift;
    $path = File::Spec->canonpath( $path );
    my $res;
    my $entries = File::Spec->catfile( $path, &DIR_SVN, &FILE_SVN_ENTRIES );
    if ( -e $entries ) {
        eval { $res = $svn_ctx->propget( "svn:externals", $path, undef, 1 ); };
    }
    if ( $res ) {
        my $ext_hash = $res;
        $res = {};
        while ( my ( $subpath, $value ) = each %$ext_hash ) {
            $subpath = File::Spec->canonpath( $subpath );
            my $rel_path = File::Spec->abs2rel( $subpath, $path );
            my @lines = split( /\s*\n\s*/, $value );
            foreach my $line ( @lines ) {
                my ( $dir, $source ) = split( ' ', $line, 2 );
                $source =~ s{/+$}{};
                $res->{ File::Spec->catfile( $rel_path, File::Spec::Unix->splitdir( $dir ) ) } = $source;
            }
        } ## end while ( my ( $subpath, $value...))
    } ## end if ( $res )
    return $res;
} ## end sub get_svn_externals($ )

sub get_svn_log($$$ ) {
    my ( $src_url, $src_rev, $dest_rev ) = @_;
    my @res = ();

    eval {
        $svn_ctx->log(
            $src_url, $src_rev,
            $dest_rev,
            0, 0,
            sub {
                shift;
                my ( $rev, $author, $date, $comment ) = @_;
                my @comments = split_lines( $comment );
                my $color    = "";
                my $creset   = "";
                my $body     = "";
                my $breset   = "";
                if ( $rev == $dest_rev ) {
                    $color  = AddonDiff::ColorSubtract;
                    $creset = AddonDiff::ColorReset;
                    $body   = AddonDiff::ColorPlain;
                    $breset = AddonDiff::ColorReset;
                } ## end if ( $rev == $dest_rev)
                elsif ( $rev == $src_rev ) {
                    $color  = AddonDiff::ColorAdd;
                    $creset = AddonDiff::ColorReset;
                }
                push @res, AddonDiff::ColorPlain . ( "-" x 72 ) . AddonDiff::ColorReset . "\n";
                push @res, $color . "r" . $rev . $creset . AddonDiff::ColorMeta . " | " . $author . " | " . $date . " | " . ( scalar @comments ) . " line" . ( @comments != 1 ? "s" : "" ) . AddonDiff::ColorReset . "\n", "\n";
                push @res, map { $body . $_ . $breset . "\n" } @comments;
            }
        );
    };
    return @res;
} ## end sub get_svn_log($$$ )

sub fetch_svn_to_path($$$$$) {
    my ( $name, $path, $url_ref, $ignore_mismatch, $auto_force ) = @_;

    # Note url_ref is probably a string before p_get_svn_root_path
    ( $path, $url_ref ) = p_get_svn_root_path( $path, $url_ref );

    my $url_cache = $svn_fetch_cache{ $url_ref->as_string } ||= {};
    unless ( exists $url_cache->{ $path } ) {
        $url_cache->{ $path } = [ 0, undef ];    # Allow early return to be treated as failure
        my $res         = $url_cache->{ $path };
        my $did_fetch   = 0;
        my $can_comment = 1;
        my $need_replace;
        my $just_update;

        my $old_svn_info = get_svn_info( $path );
        my $old_svn_url = $old_svn_info && $old_svn_info->{ &VINFO_URL };
        if ( $old_svn_url && !URI::eq( $old_svn_url, $url_ref ) ) {
            if ( $opt_flags{ "force" } ) {
                warn "Would refetch and replace: " . $name . "\n" if ( $opt_flags{ "dryrun" } );
                $need_replace = 1;
            }
            elsif ( !$ignore_mismatch ) {
                warn "Mismatch of SVN paths for addon: " . $name . ", use -force if you wish to fetch and replace.\n";
                return @$res;
            }
        } ## end if ( $old_svn_url && !...)
        elsif ( $old_svn_info && !$opt_flags{ "force" } ) {
            my $old_svn_rev = $old_svn_info->{ &VINFO_FILE_REVISION };
            if ( $old_svn_rev ) {
                my $newest_rev;
                my $old_svn_root = $old_svn_info->{ &SVN_REPOSITORY_ROOT };
                if ( $old_svn_root && !URI::eq( $old_svn_root, $url_ref ) ) {
                    $newest_rev = get_svn_revision( $url_ref );
                    if ( $newest_rev && $newest_rev == -1 ) {
                        warn "Did not get: " . $name . ": server is down\n";
                        return @$res;
                    }
                } ## end if ( $old_svn_root && ...)
                unless ( $newest_rev ) {
                    $newest_rev = $root_revisions{ $old_svn_root } if $old_svn_root;
                }
                if (   $newest_rev
                    && $newest_rev <= $old_svn_rev
                    && ( !defined $opt_source{ "revision" }
                        || ( $opt_source{ "revision" } >= $old_svn_rev ) )
                    )
                {
                    if ( $opt_output{ "verbose" } ) {
                        warn $name . " at revision " . $old_svn_rev . "\n";
                    }
                    return @$res;
                } ## end if ( $newest_rev && $newest_rev...)
            } ## end if ( $old_svn_rev )
        } ## end elsif ( $old_svn_info && ...)

        # Check to see if we need to update
        my $fetch_path = $path;
        if ( -e $path ) {
            if ( $old_svn_info ) {
                if ( $need_replace ) {
                    $fetch_path = $path . ".force";
                }
                else {
                    $just_update = 1;
                }
            } ## end if ( $old_svn_info )
            elsif ( $opt_flags{ "force" } ) {
                warn "Would refetch and replace: " . $name . "\n" if ( $opt_flags{ "dryrun" } );
                $fetch_path = $path . ".force";
            }
            elsif ( $auto_force ) {
                warn "Would refetch and replace: " . $name . "\n" if ( $opt_flags{ "dryrun" } );
                $fetch_path = $path . ".force";
            }
            else {
                warn "Missing .svn directory for addon: " . $name . ", use -force if you wish to fetch and replace\n";
                return @$res;
            }
        } ## end if ( -e $path )

        my $svn_cmd = find_command( "svn" );
        unless ( defined $svn_cmd ) {
            warn "Unable to locate svn command, make sure you have svn installed and in your path\n";
            my $open_cmd = find_command( "open" );
            if ( defined $open_cmd ) {
                do_command( $open_cmd, "http://subversion.tigris.org/project_packages.html" );
                do_command( $open_cmd, "http://metissian.com/projects/macosx/subversion/" );
            }
            else {
                warn "Check out http://subversion.tigris.org/project_packages.html to find a SVN for your computer.\n";
            }
            exit 10;
        } ## end unless ( defined $svn_cmd )

        my @args = ();
        my @cmd  = ( $svn_cmd );
        push @args, "--ignore-externals";
        if ( defined $opt_source{ "revision" } ) {
            push @args, "--revision", $opt_source{ "revision" };
        }
        if ( $just_update ) {
            push @cmd, ( "update", @args );
        }
        else {
            push @cmd, ( "checkout", @args, $url_ref->as_string );
        }
        push @cmd, $fetch_path;
        print_command( @cmd ) if ( $opt_output{ "verbose" } );
        unless ( $opt_flags{ "dryrun" } ) {
            my $svn_in = new IO::Pipe;
            $svn_in->reader( @cmd );
            my $need_print;
            my @res = ();
            while ( <$svn_in> ) {
                push @res, $_;
                next if ( m{^\s*$} );
                next if ( m{^Fetching external item into} );
                next if ( m{^(?:External a|A)t revision (\d+).$} );
                $need_print = 1;
                undef $can_comment;
            } ## end while ( <$svn_in> )
            print_command( @cmd ) if $need_print && !$opt_output{ "verbose" };
            print @res if $need_print || $opt_output{ "verbose" };
            my $any_errors = 0;
            unless ( $svn_in->close && ( $? == 0 ) ) {
                warn "Unable to get: " . $name . "\n";
                $::exit_res = 1;
                $any_errors = 1;
            }
            if ( $any_errors ) {
                undef $can_comment;
            }
            else {
                if ( ( $fetch_path ne $path ) && ( -d $fetch_path ) && !$any_errors ) {
                    my $bak_path = $path . ".bak";
                    if ( -e $path ) {
                        rmtree( $bak_path, $opt_output{ "verbose" } ) if -e $bak_path;
                        print_command( "mv", $path, $bak_path ) if ( $opt_output{ "verbose" } );
                        rename( $path, $bak_path );
                    }
                    print_command( "mv", $fetch_path, $path ) if ( $opt_output{ "verbose" } );
                    rename( $fetch_path, $path );
                } ## end if ( ( $fetch_path ne ...))
                if ( -d $path ) {
                    my $svn = get_svn_info( $path );
                    if ( ref $svn ) {
                        $did_fetch = 1;
                        my $current_rev = $svn->{ &VINFO_DIR_REVISION };
                        my $svn_root    = $svn->{ &SVN_REPOSITORY_ROOT };
                        $root_revisions{ $svn_root } = $current_rev;
                    } ## end if ( ref $svn )
                    else {
                        undef $can_comment;
                    }
                } ## end if ( -d $path )
                else {
                    undef $can_comment;
                }
            } ## end else [ if ( $any_errors ) ]
        } ## end unless ( $opt_flags{ "dryrun"...})
        $url_cache->{ $path } = [ $did_fetch, $can_comment ];
    } ## end unless ( exists $url_cache...)
    return @{ $url_cache->{ $path } };
} ## end sub fetch_svn_to_path($$$$$)

1;
